Spring Cloud & Security
프로젝트를 진행하며 스프링 게이트웨이에 인증 역할을 맡겼는데 이때 검색해본 MSA Spring Boot에서 인증(Authentication)과 인가(Authorization)의 전체 라이프사이클을 설명하며, API 게이트웨이와 각 마이크로서비스의 구체적인 역할을 알아보겠습니다.
구성 요소와 역할
| 구성 요소 | 주요 책임 | 비유 |
|---|---|---|
| 사용자 / 클라이언트 | 로그인 후 JWT를 안전하게 저장하고 요청을 시작함 | 건물에 들어가려는 사람 |
| API 게이트웨이 | 경계 보안 및 인증 모든 요청 및 JWT 검증의 최전선 |
건물 정문 경비원 |
| 사용자 서비스 | 신원 제공자 사용자 데이터 관리, 비밀번호 검증 및 로그인 성공 시 JWT 발급 |
출입증 발급 사무소 |
| 다른 마이크로서비스 | 세부 권한 부여 게이트웨이의 인증을 신뢰하고 해당 사용자 신원으로 비즈니스 논리 처리 |
특정 사무실 출입에 필요한 권한 카드 담당자 |
플로우 1: 로그인 프로세스 (토큰 획득)
사용자가 본인의 신원을 증명하고 "보안 출입증"(JWT)을 발급 받는 과정입니다.
- 로그인 요청: 사용자가 아이디와 비밀번호를
POST /users/login등 API 게이트웨이의 공개 엔드포인트로 전송합니다. - 라우팅: 게이트웨이는
/users/**경로임을 인지하고, 해당 요청을 사용자 서비스로 전달합니다. 이때 토큰은 필요 없습니다. - 비밀번호 검증: 사용자 서비스는
PasswordEncoder를 사용해 입력 비밀번호를 해시하고, DB의 저장된 해시와 비교합니다. - 토큰 생성: 비밀번호가 일치하면
JwtUtil.createToken()을 호출해 JWT를 생성합니다. 토큰에는 다음과 같은 클레임이 포함됩니다:sub(주체): 사용자 고유 ID (예:123)roles: 사용자 역할 목록 (예:["ADMIN", "CUSTOMER"])nickname: 사용자 닉네임 (예:"Alice")exp(만료시간): 토큰 만료 시점. 서명은jwt.secret키로 이루어집니다.
- 토큰 응답: 사용자 서비스는 서명된 JWT 문자열을 사용자에게 반환합니다.
- 토큰 저장: 사용자의 브라우저 또는 앱에서 JWT를 안전하게 저장합니다 (예:
localStorage나 HTTP-only 쿠키).
플로우 2: 인증된 요청 (토큰 사용)
토큰을 받은 사용자가 보호된 리소스에 접근할 때 흐름입니다.
A. API 게이트웨이 (인증)
- 토큰 포함 요청: 사용자가
Authorization: Bearer <토큰>헤더를 포함해 보호된 엔드포인트(예:GET /some-other-service/data)에 요청합니다. - Spring Security 인터셉션: 게이트웨이의
WebSecurityConfig에서oauth2ResourceServer필터가 자동 실행되어 요청을 가로챕니다. - 토큰 검증: 필터는
SecurityConfig.java에 정의된ReactiveJwtDecoder로 JWT 서명을 검증하고 만료 여부를 확인합니다. 실패하면401 Unauthorized를 반환합니다. - Principal 생성: 검증 성공 시 Spring Security가 JWT 클레임을 담은
JwtAuthenticationToken객체(Principal)를 생성합니다. - 헤더 추가: 사용자가 만든
UserInfoFilter(글로벌 필터)가 Principal에서 사용자 정보를 꺼내 다음과 같은 신뢰된 헤더로 요청에 추가합니다:X-User-IdX-User-RolesX-User-Nickname
- 라우팅: 이 인증 정보를 담은 요청은 내부 마이크로서비스로 전달됩니다.
B. 마이크로서비스 측 (인가)
- 요청 도착:
user-service등 각 마이크로서비스는X-User-*헤더가 포함된 요청을 받습니다. - 커스텀 필터 실행:
JwtAuthenticationFilter(한 번만 실행되는 필터)가 이 헤더들을 읽습니다. - 보안 컨텍스트 설정: 이 필터는
UsernamePasswordAuthenticationToken객체를 만들어SecurityContextHolder에 설정합니다. 이 컨텍스트는 요청 내내 현재 사용자의 신원 정보를 보관합니다. - 인가 검사: 컨트롤러 메서드에
@PreAuthorize("hasRole('ADMIN')")같은 권한 검사 어노테이션이 있으면,SecurityContextHolder의 인증 정보를 기반으로 검사하여 권한이 없으면403 Forbidden반환. - 컨트롤러 로직: 컨트롤러 내에서는
principal.getName()등으로 현재 사용자를 확인하고, 해당 사용자에 맞는 비즈니스 로직을 수행합니다.
이 전체 과정은 강력하고 확장성이 뛰어난 "제로 트러스트(Zero Trust)" 아키텍처를 구현합니다.
게이트웨이에서 인증을 집중 처리하고, 각 마이크로서비스에서는 상세한 권한 체크를 담당합니다.
Spring Security 필터 체인 심층 분석
Spring Security는 표준 자바 서블릿 필터(Filter) 패턴 위에서 작동하며, 애플리케이션 디스패처 전 요청/응답 사이클에 개입하여 보안을 관리합니다.
주요 컴포넌트와 과정
-
서블릿 컨테이너 (예: Tomcat)
- HTTP 요청을 수신하고
HttpServletRequest,HttpServletResponse객체 생성. - 정의된
FilterChain의 첫 필터에 요청 전달.
- HTTP 요청을 수신하고
-
DelegatingFilterProxy (Spring과 서블릿 사이 브릿지)
- 서블릿 컨테이너는 Spring 컨텍스트를 알지 못하므로, 이 프록시가 Spring
ApplicationContext내 실제 보안 필터 빈(springSecurityFilterChain)으로 위임.
- 서블릿 컨테이너는 Spring 컨텍스트를 알지 못하므로, 이 프록시가 Spring
-
FilterChainProxy (실제 보안 필터 체인 관리자)
- 여러 보안 필터를 순서대로 실행하며, 요청 URL에 맞는 첫 번째
SecurityFilterChain선택.
- 여러 보안 필터를 순서대로 실행하며, 요청 URL에 맞는 첫 번째
-
SecurityFilterChain 내 핵심 필터 예시
CsrfFilter: CSRF 공격 방어 (Stateless JWT 환경에서는 보통 비활성화)HeaderWriterFilter: 보안 헤더 추가UsernamePasswordAuthenticationFilter: 폼 로그인 처리 (JWT 인증에는 미사용)BearerTokenAuthenticationFilter/OAuth2ResourceServerFilter: JWT 인증을 담당하는 주요 필터 (게이트웨이에서 동작)- 사용자 정의
JwtAuthenticationFilter: trusted 헤더를 읽어 SecurityContext 준비 AuthorizationFilter: 최종 권한 검사 실행 (메서드 수준 권한 체크 등)
-
SecurityContextHolder (사용자 신원 저장소)
ThreadLocal을 이용해, 요청이 처리되는 스레드 단위로 현재 사용자 인증 정보를 저장.- 요청 처리 완료 시 자동 정리되어 요청 간 정보 누수 방지.
SecurityContextHolder에 Authentication 설정 순서
- 인증 필터(
JwtAuthenticationFilter)가 사용자 정보 확인. UsernamePasswordAuthenticationToken객체 생성 (사용자 ID, 권한 리스트 포함).- 이 객체를
SecurityContextHolder에 저장:SecurityContextHolder.getContext().setAuthentication(authentication); - 이후 요청 내 다른 컴포넌트가 현재 사용자 신분을 이 컨텍스트에서 확인.
DispatcherServlet 및 컨트롤러
- 인증이 문제없이 완료된 후, 요청은 Spring MVC
DispatcherServlet으로 전달. - 적절한
@RestController메서드 실행. - 컨트롤러 메서드 파라미터로
Principal타입을 받으면 Spring이SecurityContextHolder에서 현재 인증 정보를 주입.
참고: 사용자 ID와 역할 확인 코드 예시
@PostMapping("/some-endpoint")
public ResponseEntity<Result<?>> yourControllerMethod(Authentication authentication) {
String userIdAsString = authentication.getName();
Long userId = Long.parseLong(userIdAsString);
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
boolean isAdmin = authorities.stream()
.anyMatch(grantedAuthority -> grantedAuthority.getAuthority().equals("ROLE_ADMIN"));
if (isAdmin) {
System.out.println("User is an ADMIN!");
}
authorities.forEach(authority -> System.out.println("Role: " + authority.getAuthority()));
return ResponseEntity.ok(Result.success("..."));
}